iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
AI Engineering

Learning SRE for the AI Era:從 SRE Lab 到 Production AI Reliability系列 第 18 篇

Day 13(下)|SPOF、Redundancy、Failover:備援不是多開一台就結束

  • 分享至 

  • xImage
  •  

GitHub:darkstar1227/learning-sre-for-ai-era

**一句話先講完:**failover 能不能真的接手,不能只看架構圖畫了幾條線,要把決策邏輯寫成可測試的 router,串進 metrics、logs、traces 三種可觀測性訊號,並用狀態機而非 try/except 管理切換前中後的每一步。

上篇從「唯一在哪裡」出發,釐清 redundancy(資源存在)與 failover(決策加執行)是兩件事,強調自動化前要先能安全手動切換、要先寫下 failure contract,再用依賴圖找出「假備援」。這一篇接著把方法論落地成可重跑的程式碼與可觀測性合約,並整理三個常見反例與一份設計審查清單。

⑦ SRE Lab:把手動 failover 變成可重跑實驗

本節提供讀者自行在隔離環境實作的範例;程式中的 provider 都是 fake provider,目的是驗證決策與 telemetry,不是測真實供應商的可用率。

這段實作到底在驗證什麼

先把這個問題回答清楚,比看懂每一行程式碼更重要:本節的 fake provider router,驗證的是本篇 ②⑤ 節談的「decision 邏輯」,不是驗證任何真實 provider 的可靠度。

前面幾節建立了不少概念:failover 是決策加執行、不同錯誤要對應不同動作、有些任務類型完全不該自動切換。這些概念若只停在文字敘述,很容易在真的寫程式時被簡化成一句 except Exception: fallback(),⑩ 反例一正是在講這件事。這節把「router 該怎麼判斷」抽成一個可單獨測試、不依賴任何真實 API 的函式,讓讀者能用幾秒鐘跑完的測試親眼確認:同樣是 primary 失敗,「政策問答」被允許切到 fallback,「權限變更」被擋下,這個差異能被一個 assertion 驗證出來——這才是這節真正想留下的東西。

前置條件

  • 已完成 Day 9 的最小 FastAPI request path,或有能呼叫 Python 函式的隔離專案,這節不依賴 FastAPI 本身。
  • 已能從 Day 10–12 區分 timeout、輸入錯誤、retrieval failure 與 parser failure,這是 ⑤ 節錯誤分類表的前提。
  • 有一個可觀察的 request ID;Day 21 會延伸到 logs、metrics 與 traces,這節的 request_id 只是被保留、沒有真的輸出。
  • 不要把任何真實 API key 放進範例或版本控制,本節從頭到尾都是 fake provider。

第一步:把 provider 的差異藏在小介面後面

先寫一個刻意小的介面,不假裝涵蓋所有 SDK 功能,只讓 router 能要求「產生一個可驗證的回答」。

from dataclasses import dataclass
from typing import Protocol


@dataclass
class ModelResult:
    text: str
    provider: str
    degraded: bool = False


class ModelUnavailable(Exception):
    pass


class ModelClient(Protocol):
    name: str

    def generate(self, prompt: str, timeout_seconds: float) -> ModelResult:
        ...

不要急著把第三方 SDK 型別灌進 router。SDK 的 response 會變,router 需要的只是合約:成功時有何欄位、暫時失效時拋什麼例外、輸入不合法時如何表達。

為什麼先寫這個小小的 Protocol,而不是直接 import 某家 provider 的 SDK 型別? router 只需要知道三件事:成功拿到什麼、失敗怎麼知道、要不要相信這次結果。若讓 router 直接依賴某家 SDK 的型別,換 provider 或那家 SDK 升版,router 邏輯就得跟著改。這個 Protocol 把「決策邏輯」跟「怎麼跟某家 provider 講話」切開,讓 ⑤ 節的 failure contract 有一個型別檢查器能確認的落腳處。

第二步:建立兩個可控的 fake provider

PrimaryFake 模擬 timeout;FallbackFake 只回傳固定測試文字。真實測試不需要「真的等 timeout」,用立即拋出例外就能避免測試又慢又不穩。

class PrimaryFake:
    name = "primary-fake"

    def __init__(self, should_fail: bool) -> None:
        self.should_fail = should_fail

    def generate(self, prompt: str, timeout_seconds: float) -> ModelResult:
        if self.should_fail:
            raise ModelUnavailable("simulated primary timeout")
        return ModelResult(text="primary test answer", provider=self.name)


class FallbackFake:
    name = "fallback-fake"

    def generate(self, prompt: str, timeout_seconds: float) -> ModelResult:
        return ModelResult(
            text="fallback test answer",
            provider=self.name,
            degraded=True,
        )

這裡刻意沒有讓 fallback「假裝與 primary 一樣好」——degraded=True 是合約的一部分,讓下游能記錄、顯示或納入評估,而不是把切換藏起來。

為什麼用 should_fail 這種簡單布林值模擬故障,而不是真的 sleep()? 這裡要驗證的是「router 拿到失敗訊號後做了什麼決定」,不是「provider 真的逾時要花多久」——後者屬於 Day 16 的 latency 議題。立即拋出例外能把兩件事分開測試;三個測試預期幾毫秒內全跑完,若要等好幾秒,通常代表不小心引入了真實等待或網路呼叫。

第三步:把切換資格明寫成 policy

不是每個問題都能切換。下面用工作類型簡化;你的系統也許還需要資料地區、使用者權限或模型能力等條件。

SAFE_TO_FALLBACK = {"policy_question", "document_summary"}


def may_fallback(task_type: str, error: Exception) -> bool:
    return (
        task_type in SAFE_TO_FALLBACK
        and isinstance(error, ModelUnavailable)
    )

把這段規則寫成函式,至少有三個好處:能單獨測試、能在 trace 記錄 policy decision、能讓 security reviewer 明確挑戰允許範圍。藏在 except Exception 裡,三者都做不到。

這一步驗證的是 ⑤ 節「failure contract」最核心的主張:允不允許 fallback 是必須能被獨立審查的決定,不是路由邏輯的副產品。 may_fallback 只回傳一個布林值,但把它單獨抽出來,security reviewer 不需要讀懂整個 router,只看這一個函式就能回答「哪些任務類型允許在 primary 失效時切換 provider」;若寫死在 try/except 中間,同樣的問題就得逐行追蹤程式碼,這正是 ⑩ 反例一出事的根本原因。

第四步:路由時保留 request context

下面是可放進隔離專案的最小 router,重點是清楚,而不是直接處理 production 所有錯誤型別。

from dataclasses import dataclass


@dataclass
class RouteOutcome:
    result: ModelResult | None
    status: str
    fallback_triggered: bool
    reason: str


def generate_with_fallback(
    *,
    request_id: str,
    task_type: str,
    prompt: str,
    primary: ModelClient,
    fallback: ModelClient,
) -> RouteOutcome:
    try:
        result = primary.generate(prompt, timeout_seconds=2.0)
        return RouteOutcome(
            result=result,
            status="completed",
            fallback_triggered=False,
            reason="primary_completed",
        )
    except Exception as error:
        if not may_fallback(task_type, error):
            return RouteOutcome(
                result=None,
                status="unavailable",
                fallback_triggered=False,
                reason=type(error).__name__,
            )

        fallback_result = fallback.generate(prompt, timeout_seconds=2.0)
        return RouteOutcome(
            result=fallback_result,
            status="degraded_completed",
            fallback_triggered=True,
            reason="primary_unavailable",
        )

範例保留了 request_id,但沒有在函式內輸出 log,目的是把「決策」和「呈現方式」分開。正式服務應在 router 邊界以 structured log、span attribute 與 metric 記錄同一組欄位,而不是只留一行模糊的 fallback happened。

這裡最容易踩到的坑,是把 generate_with_fallback 寫成順便印 log、順便送 metric 的函式。 看起來方便,但測試「回傳了正確的 RouteOutcome」時就會出問題:函式一混雜 I/O,測試要嘛真的觸發這些 I/O,要嘛得 mock 掉一堆跟決策無關的東西。把「決策」和「呈現方式」分成兩層,⑧ 節的 observability contract 才能獨立套用在輸出上,不必每次改 log 格式都動到路由邏輯。

第五步:先寫情境測試,再把它接到 API

至少覆蓋成功、可切換、不可切換三種路徑。範例使用普通 assertion,讀者可依自己的測試框架調整。

def test_primary_success() -> None:
    outcome = generate_with_fallback(
        request_id="req-primary-ok",
        task_type="policy_question",
        prompt="test",
        primary=PrimaryFake(should_fail=False),
        fallback=FallbackFake(),
    )
    assert outcome.status == "completed"
    assert outcome.fallback_triggered is False
    assert outcome.result is not None
    assert outcome.result.provider == "primary-fake"


def test_allowed_task_uses_fallback() -> None:
    outcome = generate_with_fallback(
        request_id="req-fallback-ok",
        task_type="policy_question",
        prompt="test",
        primary=PrimaryFake(should_fail=True),
        fallback=FallbackFake(),
    )
    assert outcome.status == "degraded_completed"
    assert outcome.fallback_triggered is True
    assert outcome.result is not None
    assert outcome.result.degraded is True


def test_sensitive_task_does_not_fallback() -> None:
    outcome = generate_with_fallback(
        request_id="req-no-fallback",
        task_type="permission_change",
        prompt="test",
        primary=PrimaryFake(should_fail=True),
        fallback=FallbackFake(),
    )
    assert outcome.status == "unavailable"
    assert outcome.fallback_triggered is False
    assert outcome.result is None

如果第三個測試失敗,代表系統把「可用」放在不該自動化的行為之前。這不是測試太嚴格,而是它剛好抓到 failover 最常被忽略的安全邊界。

第六步(選用):把 bounded retry 接到 primary 呼叫前

前面五步刻意把 primary 呼叫寫成只試一次就放棄,這是為了讓測試簡單、聚焦在切換邏輯本身。真正落地時,⑤ 節談的「transient 錯誤可以有上限地重試 primary」需要一段獨立的 retry 邏輯,這裡提供一個可以接在 generate_with_fallback 之前的最小版本,讀者可自行接上:

import random
import time


def call_with_bounded_retry(
    *,
    client: ModelClient,
    prompt: str,
    max_attempts: int,
    base_delay: float,
    deadline_seconds: float,
) -> ModelResult:
    started_at = time.monotonic()
    last_error: Exception | None = None

    for attempt in range(max_attempts):
        remaining = deadline_seconds - (time.monotonic() - started_at)
        if remaining <= 0:
            break
        try:
            return client.generate(prompt, timeout_seconds=remaining)
        except ModelUnavailable as error:
            last_error = error
            # 加入 jitter:避免多個請求在同一瞬間一起重試,一起打向同一個依賴
            jittered_delay = base_delay * (2 ** attempt) * random.uniform(0.5, 1.5)
            time.sleep(min(jittered_delay, max(remaining, 0)))

    raise last_error or ModelUnavailable("retry attempts exhausted")

這段程式碼裡有兩個容易被忽略、卻正是 AWS Builders' Library 那篇文章反覆強調的細節:deadline 用「剩餘時間」而非「固定 timeout」傳給每次呼叫,否則整體耗時會遠超過使用者容忍範圍;backoff 加了 jitter,避免所有失敗請求同一瞬間集體再次打向已過載的依賴。沒有 jitter 的重試在低流量測試裡看不出問題,只在真實流量規模、大量請求同時失敗時才會現形,把一次短暫故障拖成一場重試風暴。

預期觀測結果

在你的隔離專案自行執行後,不要只看 assertion 綠色。依每個 request ID 對照應觀察到的行為。

情境 預期 provider 預期 workflow status 預期 fallback flag
primary 正常 primary-fake completed false
政策問答 primary 失效 fallback-fake degraded_completed true
權限變更 primary 失效 無 provider 結果 unavailable false

若 fallback 發生但 status 仍是 completed,你就失去了辨識降級流量的能力;若 status 是 degraded_completed 卻不知道 provider,事後也無法判斷是哪一層造成品質變化。兩個欄位必須一起保留。

就算不真的跑一次,這段實作也回答了三個問題

primary 拋出的例外是不是「自動」讓所有任務都切到 fallback?不是,是否允許切換由 SAFE_TO_FALLBACK 清單決定,跟例外本身無關。fallback 成功後,系統要怎麼知道這次回應是降級過的?靠 ModelResult.degraded 欄位,由 fallback provider 明確標記,不是事後從 log 猜出來的。如果 fallback 本身也失敗?這份最小實作沒有處理,這是刻意的範圍控制,牽涉到 ⑤ 節的 latency budget 與更複雜的狀態機,留給讀者依 ⑨ 節延伸實作。

⑧ Failover 的 observability contract

備援做得對不對,不應靠值班人員猜。至少要讓一次請求在 metrics、logs、traces 各留下不同粒度的證據。Day 21 會完整介紹三者分工,這裡先建立欄位合約。

為什麼一份 failover 事件需要三種不同粒度的紀錄,而不是一種

常有人問:能不能只留一種紀錄,同時滿足「看趨勢」和「查細節」?答案是不能,這不是工具限制,而是三種紀錄回答的問題本質上不同,硬要合而為一通常會犧牲其中一種能力:

metrics(趨勢)
  問題:過去五分鐘,有百分之幾的請求切去了 fallback?
  特性:高頻寫入、低 cardinality、便宜、可以直接設告警閾值
  代價:只留數字,看不到「是哪個 request」

logs(單點決策)
  問題:這個特定使用者這次的請求,到底發生了什麼決策?
  特性:一筆一筆記錄、可查詢單一事件、能寫下決策原因
  代價:量大時成本高,不適合直接拿來算「這五分鐘的比率是多少」

traces(跨步驟因果)
  問題:同一個請求裡,retrieval 花多久、model 什麼時候失敗、
        fallback 什麼時候接手、整體使用者等了多久?
  特性:呈現時間軸與呼叫關係,能回答「為什麼慢」而不只是「有沒有慢」
  代價:需要跨服務傳遞同一個 trace context,設計與維運成本最高

只留 metrics 答不出「是哪一類任務」「使用者實際等了多久」;只留 logs 看不出趨勢,寫入成本也會失控;只留 traces 不適合長期趨勢告警。三者是分工,不是互相替代——這也是為什麼 ⑦ 節特別強調「決策」和「呈現方式」要分開:同一組決策資料,要能同時餵給這三種輸出。

Metrics:看趨勢,不收每個 request ID

Prometheus label 必須保持低 cardinality。provider、route 與 outcome 可以;request_id、使用者 ID、完整 prompt 絕對不可以。

ai_model_requests_total{
  provider="primary-fake",
  route="primary",
  outcome="completed"
}

ai_model_requests_total{
  provider="fallback-fake",
  route="fallback",
  outcome="degraded_completed"
}

ai_fallback_triggered_total{
  task_type="policy_question",
  reason="primary_unavailable"
}

適合先看的查詢概念是 fallback 比率,而不是只看次數。但分母為零時要有處理方式:PromQL 的除法在分母是 0 時不會回傳 0,而是直接不產生任何時間序列,圖表看起來像一條平線,實際上是「這段時間沒有數據」,跟「比率確實是 0」是完全不同的兩件事,混在一起看很容易誤判成系統健康。常見做法是用 or vector(0) 補一個明確的零值:

(
  sum(rate(ai_fallback_triggered_total[5m]))
  /
  sum(rate(ai_model_requests_total[5m]))
) or vector(0)

即使加了這個保護,仍要另外檢查「有沒有請求」本身:沒有流量可能代表系統健康,也可能代表上游根本連不進來,兩者需要不同的告警(Day 15 的 availability 也有同一個分母問題)。門檻要填 ④ 節決策表裡「待量測」的數字,不是隨手抓一個看起來合理的百分比:

- alert: FallbackRatioAboveThreshold
  expr: |
    (
      sum(rate(ai_fallback_triggered_total[5m]))
      /
      sum(rate(ai_model_requests_total[5m]))
    ) or vector(0) > 0.10   # 這裡的 0.10 必須來自實際觀測,不是抄來的預設值
  for: 5m
  labels:
    severity: warning
  annotations:
    summary: "AI failover ratio 持續超過門檻,primary provider 可能發生大範圍問題"

直接抄用範例數字,門檻抓太低會讓值班人員學會忽略告警,抓太高則讓真正的大範圍 outage 遲遲不響——這正是「待量測」不能隨便填數字的原因,它決定值班人員半夜會不會被叫醒。

Logs:保留單次決策與可審計的原因

每一次 route decision 可用一筆結構化 log 表示。注意:不要記錄原始 prompt、文件全文、token 或個資,除非資料治理明確允許且已有遮罩策略。

{
  "event": "model_route_selected",
  "request_id": "req-fallback-ok",
  "task_type": "policy_question",
  "primary_provider": "primary-fake",
  "selected_provider": "fallback-fake",
  "fallback_triggered": true,
  "workflow_status": "degraded_completed",
  "reason": "primary_unavailable",
  "service_version": "example"
}

這筆 log 應能讓 incident responder 回答:何時開始切換?哪些任務被切走?有沒有不該切的敏感操作?而不是只得到「某處 timeout」這種無法行動的訊息。

以這筆 log 為例,示範幾個常見查詢在事故現場能怎麼被立刻拿來用:

查詢一:這次事故是什麼時候開始切換的?
  篩選 event = "model_route_selected" AND fallback_triggered = true,
  依 timestamp 排序找最早一筆

查詢二:有沒有敏感操作被誤放行?
  篩選 fallback_triggered = true AND task_type in (受限清單)
  正常情況下這個查詢應該永遠是空結果,如果不是,代表 ⑦ 節的 policy 判斷有漏洞

查詢三:哪些 request_id 受影響,需不需要主動通知使用者?
  篩選特定時間窗內 workflow_status = "degraded_completed" 的所有筆數

第二個查詢值得放進事故後的例行檢查:它把「權限清單有沒有被正確執行」從口頭承諾變成一條應永遠回傳空結果的查詢;真的有結果,就代表 ⑦ 節 SAFE_TO_FALLBACK 清單與實際程式碼出現了落差。

這筆 log 完全沒有 prompt 內容、使用者輸入、模型輸出,這是刻意的邊界:failover 的 observability 只回答「路由決策發生了什麼」,內容審查或品質評估屬於 Day 19 evaluation pipeline 的範圍,混在同一筆 log 只會讓它又大又難讀,還多背一份資料治理責任。

沿用 Day 2 的 Grafana Alloy + Loki 堆疊,這筆 JSON log 會由 Alloy 從容器 stdout 收集、送進 Loki,直接用 LogQL 寫出上面三個查詢,不需要另外接一套日誌分析工具。

Traces:保留跨步驟的時間與因果

一次 AI workflow 可以有這樣的 span 關係:

POST /ask
└─ ai.workflow
   ├─ retrieval
   ├─ model.primary (error)
   ├─ failover.policy
   ├─ model.fallback
   ├─ response.parser
   └─ response.validator

建議的 span attribute 是 ai.route、ai.provider、ai.fallback_triggered、ai.workflow_status、ai.task_type 與安全的錯誤分類。完整 prompt、完整回應、Authorization header、使用者姓名不應為了方便而直接塞進 span。

如果 trace 顯示 primary 失敗後 fallback 成功,但使用者仍看到 timeout,該檢查的是整體 request deadline、parser 與 validator,而不是只替 fallback 加更多重試——這就是 distributed tracing 在 failover 場景的價值:讓你看見「成功」是不是在太晚的時候才發生。

把這些欄位接到 Day 2 的 OTel Collector

沿用 Day 2 建立的 OpenTelemetry Collector + Tempo 這套 SRE Lab,把上面提到的 span attribute 實際寫進 router 的程式碼,大致長這樣:

from opentelemetry import trace

tracer = trace.get_tracer("failover-router")


def generate_with_fallback_traced(*, request_id, task_type, prompt, primary, fallback):
    with tracer.start_as_current_span("ai.workflow") as span:
        span.set_attribute("ai.task_type", task_type)
        span.set_attribute("ai.request_id", request_id)

        with tracer.start_as_current_span("model.primary") as primary_span:
            try:
                result = primary.generate(prompt, timeout_seconds=2.0)
                primary_span.set_attribute("ai.provider", result.provider)
                span.set_attribute("ai.route", "primary")
                span.set_attribute("ai.fallback_triggered", False)
                span.set_attribute("ai.workflow_status", "completed")
                return result
            except Exception as error:
                primary_span.record_exception(error)
                primary_span.set_attribute("ai.error_class", type(error).__name__)

        with tracer.start_as_current_span("failover.policy") as policy_span:
            allowed = may_fallback(task_type, error)
            policy_span.set_attribute("ai.fallback_allowed", allowed)

        if not allowed:
            span.set_attribute("ai.route", "none")
            span.set_attribute("ai.workflow_status", "unavailable")
            return None

        with tracer.start_as_current_span("model.fallback") as fallback_span:
            result = fallback.generate(prompt, timeout_seconds=2.0)
            fallback_span.set_attribute("ai.provider", result.provider)
            span.set_attribute("ai.route", "fallback")
            span.set_attribute("ai.fallback_triggered", True)
            span.set_attribute("ai.workflow_status", "degraded_completed")
            return result

這段程式碼刻意把每個決策點包成獨立的 span,而不是全部塞進 ai.workflow 這一個 span 的屬性裡。獨立的 span 各自帶有開始與結束時間,Tempo 的火焰圖才能呈現「primary 花了多久才失敗」「fallback 花了多久成功」這種時間軸關係,這正是只有 traces 能回答的問題。span 屬性裡只放 provider、route、workflow_status 這類低風險欄位,完全沒有 prompt 或使用者輸入,跟前面對 metrics label、log 欄位的紀律是同一套原則。

⑨ 切換前、切換中、切換後各要問什麼

把 failover 當成一個狀態機,比把它當成 try/except 更容易審查。

try/except 只有「正常」和「失敗後立刻反應」兩種狀態,沒有觀察窗、沒有緩衝。狀態機拆成五個階段,每個階段有明確的進入與離開條件,允許系統先「停留一段時間」再決定下一步,而不是一收到錯誤訊號就採取最終行動。⑩ 節反例二談的健康檢查誤判,很大一部分就是因為系統只有 healthy/unhealthy 兩態、沒有 suspect 緩衝,才會一次偶發逾時就觸發切換。

healthy
  │ primary failure signal
  ▼
suspect
  │ independent evidence / policy allows
  ▼
degraded
  │ primary recovery evidence + safe rollback check
  ▼
recovering
  │ stable observation window
  ▼
healthy

切換前:訊號是否可信?

  • 是單一請求超時,還是一段時間內的比例升高?
  • client timeout、server timeout、DNS error 是否混在同一個計數裡?
  • 依賴真的壞了,還是我們自己的 egress、憑證或序列化壞了?
  • 是否已有同一故障域的告警,足以支持切換?
  • 此任務是否落在 policy 允許的 fallback 範圍?

單一訊號不是不能用,但要說清楚風險。高風險行為可選擇寧可短暫不可用,也不做自動切換;低風險讀取工作可以接受較快的降級。

把狀態機從圖轉成程式碼,其實不需要多複雜的框架,一個帶著少量狀態的小 class 就足以表達核心概念:

from enum import Enum, auto
from dataclasses import dataclass, field
import time


class DependencyState(Enum):
    HEALTHY = auto()
    SUSPECT = auto()
    DEGRADED = auto()
    RECOVERING = auto()


@dataclass
class DependencyHealth:
    state: DependencyState = DependencyState.HEALTHY
    suspect_since: float | None = None
    recovering_since: float | None = None
    suspect_window_seconds: float = 30.0
    recovery_window_seconds: float = 120.0

    def record_failure_signal(self) -> None:
        now = time.monotonic()
        if self.state == DependencyState.HEALTHY:
            self.state = DependencyState.SUSPECT
            self.suspect_since = now
        elif self.state == DependencyState.SUSPECT:
            if now - self.suspect_since >= self.suspect_window_seconds:
                self.state = DependencyState.DEGRADED
        # DEGRADED 狀態下再收到失敗訊號,不需要做任何事,已經在用 fallback 了

    def record_success_signal(self) -> None:
        now = time.monotonic()
        if self.state == DependencyState.SUSPECT:
            # 單一次成功不足以宣告恢復,退回 healthy 前仍要求獨立確認
            self.state = DependencyState.HEALTHY
            self.suspect_since = None
        elif self.state == DependencyState.DEGRADED:
            self.state = DependencyState.RECOVERING
            self.recovering_since = now
        elif self.state == DependencyState.RECOVERING:
            if now - self.recovering_since >= self.recovery_window_seconds:
                self.state = DependencyState.HEALTHY
                self.recovering_since = None

這個小 class 沒有連任何真實的 metrics 系統,record_failure_signal 與 record_success_signal 應由 ⑦ 節 router 每次呼叫完 primary 之後呼叫。留下兩個簡化:suspect_window_seconds、recovery_window_seconds 是常數,實務上要回到 ④ 節「待量測」的紀律用真實流量定案;也沒有實作「觀察窗內失敗比例」這種更細判斷,只用「持續 suspect 超過一段時間」。

這五個問題對照狀態機,正是 healthy 要不要進入 suspect 的判斷條件。值得強調第三個:一個過期的 TLS 憑證、一條被擋住的出口網路,癥狀跟 provider 真的掛掉幾乎一樣,但修法完全不同,切到 fallback 只會重複同一次失敗,還把根因掩蓋得更深。

切換中:避免把事故擴大

  • retry 是否受 deadline、attempt 數與 jitter 約束?
  • 每個 request 是否最多選擇一條 fallback 路徑,避免 provider ping-pong?
  • 是否把 primary 與 fallback 的成本、延遲、輸出版本分開記錄?
  • 是否明確傳遞 degraded,讓 validator 能採取更嚴格的處置?
  • 對有副作用的 tool action,是否停止而不是重送?

AWS Builders' Library:Timeouts, retries, and backoff with jitter提醒,未加控制的重試可能讓已過載的依賴更難恢復,這也是為什麼 routing、retry 與 circuit breaker 必須一起設計,而不是各自塞一段 middleware。

「provider ping-pong」是「多 provider 反而更不可靠」最具體的發生機制:primary 逾時切到 fallback,fallback 剛好也短暫過載,系統「聰明地」切回 primary,primary 短暫成功後又遇到下一波過載再度逾時——每個環節單獨看都合理,合起來卻是不斷來回甩動、沒有一刻真正穩定的系統。

沒有狀態約束的來回切換:

primary 逾時 → 切 fallback
fallback 短暫過載 → 切回 primary
primary 又逾時 → 切 fallback
...(沒有機制阻止這個循環)

加上狀態機約束後:

primary 逾時 → 進入 suspect
suspect 條件持續成立 → 進入 degraded(穩定選用 fallback,不再每次請求重新評估)
primary 恢復訊號持續一段觀察窗 → 進入 recovering
recovering 確認穩定 → 回到 healthy

差別在於:狀態機版本讓「這次要不要切」不再是每個請求各自獨立判斷,而是取決於系統目前所在的狀態;一旦進入 degraded,短時間內就穩定走 fallback,不會因為 fallback 有一次抖動就立刻反彈回 primary。

circuit breaker 跟 retry 不是同一層的東西

「routing、retry 與 circuit breaker 必須一起設計」這句話裡的 circuit breaker,容易被誤會成 retry 的某種進階版本,但兩者解決的其實是不同層級的問題,值得說清楚差異:

retry:個別請求層級
  這一次請求失敗了,我該不該再試一次?

circuit breaker:依賴整體層級
  這個依賴最近的失敗率已經高到一定程度,
  接下來一段時間,乾脆不要再讓任何請求打過去,直接快速失敗或走 fallback

沒有 circuit breaker 時,就算每個請求各自的 retry 邏輯都合理,依賴大範圍故障時系統仍會讓每個新請求走完一輪 retry 才放棄,不只沒能提早止血,還持續用大量重試騷擾一個已在掙扎的依賴。circuit breaker 偵測到失敗率超過門檻後直接把「開關」打開,跳過真的呼叫依賴這一步,讓後續請求快速走向 fallback 或快速失敗,讓依賴有喘息空間恢復。

circuit breaker 本身也是一個小狀態機,常見的三態版本長這樣:

closed(正常)
  │ 失敗率超過門檻
  ▼
open(開路,直接快速失敗或走 fallback,不再真的呼叫依賴)
  │ 冷卻時間過後
  ▼
half-open(放行少量測試請求,觀察依賴是否已恢復)
  │ 測試請求成功 → closed
  │ 測試請求仍失敗 → 回到 open

這個三態結構跟本節開頭的五階段狀態機幾乎是同一種思路的不同呈現:open 對應 degraded,half-open 對應 recovering,closed 對應 healthy。若技術棧已有現成的函式庫,不必重新發明狀態機,直接把 ⑤ 節的錯誤分類與 ⑦ 節的 policy 判斷接進去即可。

切換後:何時回切?

回切常被遺忘,因為 outage 結束時大家都想收工。可是 primary 剛恢復時若立刻把所有流量切回去,可能再次打爆尚未穩定的依賴。

機制很具體:primary 剛恢復時,連線池、快取、內部佇列大多是「冷」的,第一波流量比平常昂貴。若把所有流量一次切回去,等於用「狀態最脆弱」的那一刻承接完整正常流量,很容易撐不住而再次觸發故障、又切回 fallback,形成一種比前面 provider ping-pong 更緩慢但破壞力類似的循環。這正是「漸進比例」存在的原因:讓 primary 在真正壓力下逐步暖機。

一個最小的漸進回切排程,可以簡單到只是一份人工照著執行的表格:

階段  流量比例   觀察窗       進入下一階段的條件
1     5%        10 分鐘      成功率與延遲落在正常區間,且沒有新的錯誤訊號
2     25%       15 分鐘      同上,且沒有出現階段一沒看到的錯誤類型
3     75%       15 分鐘      同上
4     100%      持續觀察      正式視為已完全回切,關閉本次 incident 的技術追蹤

不需要一開始就交給自動化;先讓值班人員照表手動調整流量比例、記錄每階段觀察結果,就已經比「立刻整批切回」安全得多。任何階段觀察窗內出現異常,直接退回上一個穩定比例,而不是繼續往下推進。

回切過程中,⑧ 節的 telemetry 欄位要繼續記錄請求走的是 primary 還是 fallback,讓事後能驗證流量比例是否真的按表格階段推進。

回切紀律跟本篇開頭的 SPOF 盤點,是同一個習慣的兩種展現

上篇「先找唯一在哪裡」跟這裡的「漸進回切」,一個發生在故障之前、一個在故障之後,但背後是同一種工程習慣:不相信「看起來應該沒問題」,要求明確、可檢驗的證據。這個習慣會在 Day 14 的 SLI/SLO/SLA、Day 15 的 availability 定義裡反覆出現。

問題 應留下的明確答案
什麼證據證明 primary 恢復? health、成功率與延遲在觀察窗內符合條件
誰能啟動回切? 自動 policy 或指定 on-call 角色
回切速度是多少? 漸進比例或明確的手動步驟
哪些請求不能回切? 不支援雙寫、尚在長任務中的工作
回切失敗怎麼辦? 回到 degraded,並建立 incident timeline

不要把「provider status page 顯示 resolved」當成自己的恢復證據。它可以是輸入,但你的服務仍需從自己的成功率、延遲與 workflow outcome 確認使用者真的回到穩定路徑。

這跟 GitHub 2018 年那次「每一步判斷都對,合起來卻錯了」是同一種邏輯錯誤:provider 的 status page 只反映它自己系統的健康狀態,不知道你的服務跟它之間那條特定網路路徑、那組憑證是否也已一起恢復。把別人的綠燈當成自己的綠燈,正是本篇反覆強調「用完整依賴圖找假備援」想避免的思維習慣,只是這次出現在「恢復」而非「故障」的判斷上。

⑩ 反例:三種看似合理、實際會出事的設計

反例一:所有 exception 都切 fallback

try:
    return primary.generate(prompt)
except Exception:
    return fallback.generate(prompt)

這段很短,也很危險。ValueError 可能代表 prompt 組裝錯誤;PermissionError 可能代表憑證設定錯;parser exception 可能根本不是 provider 的錯。把它們通通送到 fallback,只會讓原始原因消失,還會增加成本。

這段程式碼危險的地方不在於它「會出錯」,而在於它「大部分時候看起來運作正常」。假設 prompt 組裝邏輯有個 bug,在某 5% 的輸入下拋出 ValueError,系統不會報錯,而是把這 5% 都送去 fallback,得到技術上「成功」的回應,dashboard 上只看得到「fallback 比率略微升高」,沒有人會想到根因跟 provider 無關。這也是為什麼 ⑧ 節堅持 reason 欄位要記錄實際例外類型,而不是只記 fallback_triggered: true。

修正方向是先分類錯誤、限制可切換任務、在 outcome 保留原因。未知錯誤寧可安全失敗,也不要靜默換路。

# 反例:把所有例外都送去 fallback
try:
    return primary.generate(prompt)
except Exception:
    return fallback.generate(prompt)

# 對照 ⑤ 節的分類與 ⑦ 節的 policy 之後:
try:
    return primary.generate(prompt, timeout_seconds=budget.primary)
except ModelUnavailable as error:          # 已知的 transient / overload 類別
    if may_fallback(task_type, error):
        result = fallback.generate(prompt, timeout_seconds=budget.fallback)
        return RouteOutcome(result=result, status="degraded_completed",
                             fallback_triggered=True, reason="primary_unavailable")
    return RouteOutcome(result=None, status="unavailable",
                         fallback_triggered=False, reason=type(error).__name__)
except (ValueError, PermissionError) as error:  # 明確不該切換的類別
    raise                                        # 讓它照原本的錯誤路徑處理,不偽裝成 provider 問題

差別看起來只是多寫了幾行,意義卻完全不同:「這個例外算不算 provider 的問題」不再是隱含的假設,而是被明確寫成兩條分開的路徑。ValueError 與 PermissionError 被重新拋出,代表系統承認「這不是 provider 的錯,換個 provider 解決不了」——failover 是需要被授權的決定,不是遇到任何錯誤時的預設反射動作。

反例二:健康檢查只打 /healthz

API pod 回 200,不代表它能解析模型憑證、連到 egress、呼叫 provider、解析回應或通過 validator。深度 health check 也不能每秒跑一個昂貴 LLM 呼叫;比較實際的是組合多個廉價訊號:依賴錯誤率、近期成功 request、憑證有效期、router 決策與合成測試。

具體來說,一個組合式健康訊號大致長這樣:

healthy =
    recent_error_rate(window=60s) < threshold_error
    AND recent_success_count(window=60s) >= threshold_min_traffic
    AND credential_expiry > now + safety_margin
    AND last_synthetic_probe_result == "ok"
    AND last_synthetic_probe_age < max_probe_staleness

每一項都是刻意挑過的廉價訊號:recent_error_rate 和 recent_success_count 來自 router 已在記錄的真實流量;credential_expiry 是本地讀取;只有 synthetic_probe(定期打一個很便宜的合成請求)才真的碰觸外部依賴,且頻率跟「最近有沒有真實流量」脫鉤。這五個條件裡任何一個轉紅,不代表其餘也一起轉紅:憑證快過期但錯誤率正常是另一種故障模式,不該被單一布林值吃掉細節。

切換策略需要列出採用哪些訊號、以及每個訊號的盲點,單一「萬能健康檢查」不存在:recent_error_rate、recent_success_count 幾乎即時,但流量太低時容易誤判;credential_expiry 可預測,但有效不代表權限範圍正確;synthetic_probe 依探測頻率而定,探測內容太簡單測不出邊界情況。沒有任何訊號能單獨代表「健康」,這也是為什麼組合判斷要用 AND 而不是 OR——用 OR 等於讓最寬鬆、盲點也最大的訊號主導整體判斷。

只打 /healthz 會出事的具體時間軸

primary 的憑證簽發服務悄悄故障,/healthz 仍回 200;router 的真實請求因缺少有效 token 被拒絕(401),但健康判斷只認 /healthz;使用者持續拿到失敗,dashboard 燈號卻從頭到尾是綠的,直到值班人員被投訴驚動才發現「健康」跟「真的能用」是兩件事。系統從第一個失敗請求起就已有足夠訊號(401 錯誤率飆升)判斷「這裡不健康」,只是健康判斷邏輯從未讀這份訊號——⑧ 節的 ai_model_requests_total 本來就會記錄這些 401,若願意讀這份已存在的資料,第一時間就足以觸發 ⑨ 節狀態機從 healthy 進入 suspect。

反例三:fallback 成功就算 incident resolved

fallback 可能讓技術可用率恢復,卻讓品質、成本或延遲顯著惡化。Day 7 的分層在此非常實用:

technical success: fallback call returned
workflow success: parser and validator completed
semantic success: answer is grounded and policy-compliant
task success: user can finish the intended work

只有第一層成立時,不能宣稱服務已恢復。至少要把降級期間的品質抽樣或離線 evaluation 放回 Day 19 的 task success 討論,而不是把它混進一般 availability。

這個反例特別容易發生在慶祝的時刻:on-call 半夜處理完事故、fallback 成功接手、錯誤率曲線壓平,第一個念頭往往是「解決了」,把 incident 標記 resolved 就去睡覺。這個判斷在 technical success 這一層是對的,卻可能在其他三層留下未被檢視的風險:fallback model 準確率比 primary 低多少?降級期間有沒有使用者拿到錯誤回答,卻因為 HTTP 200 而沒觸發告警?

比較穩妥的做法,是把「技術上恢復」和「incident 真正結束」分成兩個里程碑:技術恢復(fallback 穩定接手,可停止告警升級,但 incident 保持開啟)與 incident 真正結束(技術恢復已維持觀察窗、降級期間的輸出品質已抽樣或跑過離線 evaluation、確認沒有需要事後修復或通知使用者的錯誤結果、primary 已恢復或明確決定繼續用 fallback)。把兩者分開寫進流程,能避免「fallback 成功」被誤當成「萬事大吉」,這正是 ⑫ 節要談的:備援從來不是一次做完就收工的工程,而是一份需要持續償還的債。

⑪ 今天可以完成的設計審查

這份清單不要求你已經有多 region,也不要求你花錢開第二家 provider。它要求的是把目前的可靠性假設攤開來看。

清單裡的十個項目對應本篇前十節各自留下的具體產出,可以當成「本篇讀完了沒有」的自我檢核,不只是給團隊用的審查表;如果有好幾項答不出來,不代表白讀了,而是 failover 這個主題最誠實的樣子——它不是一次寫完程式碼就結束的功能,而是一連串需要隨系統演進不斷重新檢視的問題。

  • [ ] 為一條 critical path 畫出 provider、data、configuration、network 與控制面的依賴。
  • [ ] 每個「備援」都標註共同故障域;未知就寫未知。
  • [ ] 將至少三種錯誤分類為 retry、fallback 或 fail closed(遇到不確定情況寧可拒絕也不放行,⑤ 節「執行付款、刪除或權限變更」列的「拒絕並要求明確重試」即是;相對的 fail open 傾向放行,本篇在高風險操作上一律建議 fail closed)。
  • [ ] 為每種允許 fallback 的 task type 寫出使用者可見結果。
  • [ ] 明確列出禁止自動重送的有副作用操作。
  • [ ] 為每條路由寫一個最小測試情境,不呼叫真實 provider。
  • [ ] 定義 provider、route、workflow_status 與 fallback_triggered 的 telemetry 欄位。
  • [ ] 確認 metrics 不含 request ID、prompt、使用者 ID 等高 cardinality 或敏感內容。
  • [ ] 寫下 primary 恢復後的觀察窗與回切責任人;若尚未決定,就標示待決策。
  • [ ] 保留一份演練紀錄,包含假設、結果、未驗證項目與下一次改善。

驗收時不要過度宣稱

若你只完成 fake provider 的單元測試,能誠實宣稱的是「router 對已列情境的決策符合測試預期」,不能證明真實 provider、DNS、SDK、網路、配額、跨區資料一致性或自動回切在 production 一定可用。若完成隔離環境演練,可再記錄實際命令、版本、時間與 observable result——這是 Day 30 chaos experiment 會反覆使用的證據格式:假設與實際結果並列,不讓「我以為會切換」冒充「它確實切換了」。

這不是在澆冷水,而是示範一種紀律:把「驗證過什麼」和「還沒驗證什麼」分開寫下來,比含糊宣稱「failover 已完成」更有價值。AWS us-east-1 的 DNS 事故裡,不少受影響團隊事前都以為多可用區部署「已經是備援完成」,直到控制面失效才發現假設從未被驗證。verified 和 assumed 之間的界線,要用演練紀錄去畫,不能用信心去畫。

⑫ Production takeaway:備援是一份持續要還的債

每增加一條 fallback 路徑,就增加一組需要測試的輸出契約、權限、成本、資料治理與 runbook。這不代表不要備援;代表先把可承擔的範圍做對,再擴大。

備援也是 Economic Reliability 的一部分

本系列開頭的七層框架裡,Economic 這一層談 Token Cost、GPU Utilization、Retry Amplification,failover 幾乎同時踩中這三項:多一個 fallback provider 就多一份 token 帳單;bounded retry 代表每次 primary 失敗都可能連續產生兩三次計費呼叫;「Retry Amplification」正是 ⑨ 節的 provider ping-pong 與沒有 jitter 的重試,把小規模故障放大成數倍呼叫量與帳單。

這代表 failure contract 除了「使用者結果」與「不做什麼」,還該有第三個常被漏掉的欄位:這個決定的成本上限是多少。「允許 fallback」等於在說「我願意為了可用性多付出這麼多 token 成本」,沒有明確上限,一次大範圍 outage 可能讓當月帳單出現誰都沒預期到的尖峰——這是一筆用金錢換可用性的交易,該被明確計算過,而不是隱含在程式碼裡的副作用。

「持續要還的債」跟一般技術債不太一樣:就算當初做得再仔細,它也會隨時間自然「利息累積」,因為周圍一切都在變動——系統本身在長(新增 tool 或 workflow 分支,就多一條路徑要問 SPOF 在哪);依賴在變(provider 升級 SDK、DNS 服務商調整基礎設施,驗證過的 fallback 行為可能因對方一次無聲變更就不再成立);組織在變(知道怎麼手動切換的工程師離職,runbook 上「聯絡誰」那一欄早換了角色);驗證本身也在過期(三個月前跑過的 fake-provider 演練,router 邏輯已改了兩次,結果早不代表現在的行為)。

這四種利息說明了為什麼備援不能是「做完一次就打勾」的項目:它更接近保養一套持續運轉的系統,而不是蓋完就結案的建案。上篇 ⑥ 節故障域標記表裡的 last failover exercise 欄位,正是為了讓「這項驗證是不是已過期」一眼看出來,而不是被架構圖上一份看似永遠正確的綠色標記掩蓋。

Google SRE 對可靠性的核心提醒也適用於此:以可衡量的目標管理可靠性與變更風險,而不是宣稱能消滅所有 failure。Google SRE Book:Service Level ObjectivesDay 14 會把「什麼結果值得量」正式變成 SLI、SLO、SLA 的語言——不是「我們有 failover」這種是非題,而是「fallback 比率過去 30 天的分布」這種可以拿來做決策的連續數字。

幾個讀完全篇後常見的追問

「團隊只有三個工程師,這篇是不是給大團隊看的?」 不是。本篇沒有任何練習要求真的多開一台伺服器或多付一家 provider 的錢,④⑥⑨ 的產出全是紙上或本地程式碼就能完成的。小團隊真正該做的取捨,是把 ⑪ 節清單誠實標成「現在做」「刻意不做」「還不知道」三類,而不是假裝已做完所有項目——「刻意不做」跟「沒空做」的差別,事故發生時會完全反映在 on-call 的心態上。

「已經有雲端服務商提供的多可用區部署,是不是就不用再做本篇談的事了?」 多可用區部署解決的是上篇 ①「唯一的 instance」這一層,但 AWS DNS、Google Cloud Service Control、Meta backbone 三案例,全都發生在「已有多可用區部署」的公司身上,根因都不在 instance 這一層。上篇 ⑥ 節的控制面、⑤ 節的失敗合約、這篇 ⑨ 節的回切紀律,是雲端 SLA 保證不到、只能自己補齊的部分。

「該從哪一件事開始做?」 如果只能選一件,選上篇 ⑥ 節的完整依賴圖,只畫一條 critical path 就好。Meta、AWS、Google Cloud 三個真實事故的共同教訓,都是「以為有備援的地方其實共用了一個沒被畫出來的依賴」,依賴圖正是唯一能直接暴露這個盲點的動作;跳過依賴圖直接寫 failover 程式碼,等於在不知道敵人長相的情況下設計防禦。

⑬ 本文結論

備援的目的不是讓架構圖更漂亮,而是讓一個已知失敗不必變成使用者 failure。接下來進入 SLI/SLO/SLA:先決定什麼結果值得被量。

Day 14 預告

Day 13 處理的是「一個節點倒下之後」的路徑問題:SPOF、redundancy、failover。Day 14 往回問一個更根本的問題,備援做好之後,到底要拿什麼指標證明系統真的可靠?會從 SLI、SLO、SLA 這套 SRE 最核心的語言開始。

延伸閱讀

依自己最想深入的部分挑著讀:Google SRE 兩篇對應 ⑫ 節,是 Day 14 SLI/SLO/SLA 的前導閱讀;Meta、AWS US-EAST-1、Google Cloud 三案例對應上篇 ⑥ 節的控制面 SPOF;GitHub 事故對應上篇 ②、這篇 ⑨ 節;Statsig 對應上篇 ⑤ 節;SD Times 對應上篇 ③ 節;AWS Builders' Library 對應這篇 ⑦⑨ 節的 bounded retry 與 jitter。


這篇是 Learning SRE for the AI Era 系列的一部分。
Build → Trace → Break → Measure → Evaluate → Recover → Improve.


上一篇
Day 13(上)|SPOF、Redundancy、Failover:備援不是多開一台就結束
下一篇
Day 14(上)|SLI、SLO、SLA:先量承諾,再談百分比
系列文
Learning SRE for the AI Era:從 SRE Lab 到 Production AI Reliability 共 44 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言